Skip to content

security: refuse to write credentials through pre-existing symlinks#94

Open
garagon wants to merge 3 commits into
stripe:mainfrom
garagon:security/credential-output-symlink-toctou
Open

security: refuse to write credentials through pre-existing symlinks#94
garagon wants to merge 3 commits into
stripe:mainfrom
garagon:security/credential-output-symlink-toctou

Conversation

@garagon
Copy link
Copy Markdown
Contributor

@garagon garagon commented May 9, 2026

Summary

writeCredentialFile (packages/cli/src/utils/credential-output.ts, added in #67) is vulnerable to a TOCTOU exfiltration on shared filesystems. The previous flow was:

fs.access(resolved);                                  // follows symlinks
fs.writeFile(resolved, ..., { mode: 0o600 });         // follows symlinks;
                                                      // mode is only applied
                                                      // when the file is
                                                      // created, not when it
                                                      // already exists
fs.chmod(resolved, 0o600);                            // follows symlinks;
                                                      // chmod's the target

If the operator runs spend-request retrieve <id> --output-file <path> --force and an attacker on the same filesystem (CI runner, multi-tenant host, container with shared /tmp) pre-plants a symbolic link at <path> pointing at a file the attacker can already read, the attacker can:

  1. Plant <path> as a symbolic link to /tmp/<readable>.
  2. Open a read fd on /tmp/<readable> while it is still world-readable.
  3. Operator runs the command — writeFile follows the symlink and writes the full card credential (PAN, CVC, billing address, valid_until) to the symlink target.
  4. Operator's fs.chmod then locks the target down to 0o600 — too late, the attacker's fd was opened before the chmod and survives it (open fds keep their access mode regardless of subsequent permission changes).

Verified end-to-end by a Node reproduction. Pre-fix, the attacker fd reads the JSON-encoded credential immediately after the operator's writeCredentialFile call returns:

attacker planted symlink → target
attacker pre-opened fd while target was 0o644
victim running writeCredentialFile with --force ...
attacker fd read after victim wrote:
{
  "card_number": "4242-4242-4242-4242",
  "cvv": "123"
}
target file mode after victim chmod: 600     ← chmod ran but fd was already open
symlink still a symlink: true

Fix

Replace fs.access + fs.writeFile + fs.chmod with a single atomic open:

fs.open(resolved,
  constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY | constants.O_NOFOLLOW,
  0o600);
  • The mode is set at create time, so the chmod follow-up step is unnecessary (and the symlink-following bug it introduced is gone).
  • O_NOFOLLOW makes open fail with ELOOP if the final path component is a symbolic link.
  • O_EXCL makes open fail with EEXIST if the file already exists.
  • Force-mode unlinks any pre-existing entry first via fs.unlink (operating on the symlink itself rather than the target via fs.writeFile), then takes the same atomic-create path. A re-planted symlink that races into place between the unlink and the open causes open to fail-closed instead of writing through.

Post-fix verification:

attacker planted symlink → target
attacker pre-opened fd to target
--- attempt without force ---
refused: OUTPUT_FILE_EXISTS: /tmp/link-poc-symlink2
--- attempt with force=true (after force unlink, target should be untouched) ---
attacker fd read of target after victim wrote with --force:
  contents: BEFORE                                   ← target untouched
  ↑ if "BEFORE" — target untouched (fix works)
symlinkPath now:
  isSymlink: false                                   ← real file, not symlink
  isFile: true

Tests

packages/cli/src/utils/__tests__/credential-output.test.ts — four new cases:

  • refuses to write through a symbolic link without force — symlink at the target path causes OUTPUT_FILE_EXISTS, and the link target is unchanged.
  • refuses to write through a symbolic link with force--force unlinks the symlink and atomically creates a fresh regular file at the path. The original link target is unchanged. The new file is a regular file (not a symlink) with mode 0o600.
  • refuses to write through a symlink that races into place between unlink and create — sanity check on the --force race-shape. The fail-closed semantics are guaranteed by O_EXCL | O_NOFOLLOW.
  • produces a 0o600 file even when force is set — regression guard for the removed fs.chmod step. Mode is now set at open() time via the third argument.
$ pnpm vitest run src/utils/__tests__/credential-output.test.ts
Test Files  1 passed (1)
     Tests  8 passed (8)
$ pnpm test
@stripe/link-sdk:test Test Files  10 passed (10)
@stripe/link-sdk:test      Tests  82 passed (82)
@stripe/link-cli:test  Test Files  11 passed (11)
@stripe/link-cli:test       Tests  117 passed (117)

Negative control: with the implementation reverted, two of the new tests fail because the symlink target is overwritten by the credential JSON (the existing it('overwrites existing file with force', …) test still passes — the previous behavior was technically "compatible" with that assertion).

Test plan

  • pnpm vitest run src/utils/__tests__/credential-output.test.ts green (8/8)
  • pnpm test green
  • pnpm typecheck green
  • Negative control: revert the implementation, the two symlink tests fail with the credential JSON appearing in the symlink target's contents
  • End-to-end reproduction against the patched module: a pre-planted symlink + pre-opened attacker fd no longer expose the credential. Without --force, the call refuses up front. With --force, the symlink is replaced by a fresh regular file at the target path; the original symlink target is unmodified and the attacker fd reads the pre-existing contents.

Files

packages/cli/src/utils/credential-output.ts        | 58 +++++++++++++--
packages/cli/src/utils/__tests__/credential-output.test.ts | 85 ++++++++++++++++++++++
2 files changed, 135 insertions(+), 8 deletions(-)

writeCredentialFile (cli/src/utils/credential-output.ts) was vulnerable to
a TOCTOU exfiltration on shared filesystems. The previous flow was:

  fs.access(resolved)         // follows symlinks
  fs.writeFile(resolved, ..., { mode: 0o600 })  // follows symlinks; mode
                                                // applied only if file is
                                                // created, not if exists
  fs.chmod(resolved, 0o600)   // follows symlinks; sets perms on TARGET

If the operator runs `spend-request retrieve <id> --output-file <path>
--force` and an attacker on the same filesystem (CI runner, multi-tenant
host, container with shared /tmp) pre-plants a symlink at <path> pointing
at a file the attacker can already read, the attacker:

  1. Plants <path> as a symbolic link to /tmp/<readable>.
  2. Opens a read fd on /tmp/<readable> while it is world-readable.
  3. Operator runs the command: writeFile follows the symlink and writes
     the full card credential (PAN, CVV, billing address, valid_until) to
     the symlink target.
  4. Operator's chmod 0o600 then locks the target down — too late, the
     attacker's fd was opened before the chmod and survives it.

Verified end-to-end by a Node reproduction: a fresh fd opened against the
target before the operator's writeCredentialFile call reads the JSON-
encoded credential immediately after the call returns, regardless of the
chmod that follows.

Fix replaces fs.access + fs.writeFile + fs.chmod with a single
fs.open(resolved, O_CREAT | O_EXCL | O_WRONLY | O_NOFOLLOW, 0o600). The
mode is set at create time. O_NOFOLLOW makes open fail with ELOOP if the
final path component is a symlink. O_EXCL makes open fail with EEXIST if
the file already exists. Force-mode unlinks any pre-existing entry first
(operating on the symlink itself via fs.unlink, not the target via
fs.writeFile), then takes the same atomic-create path.

Tests added to credential-output.test.ts:
- refuses to write through a symbolic link without force
- refuses to write through a symbolic link with force (target untouched)
- TOCTOU race-fail-closed
- 0o600 mode produced even with --force (regression guard for the
  removed fs.chmod step)

`pnpm test` is green (117/117 across 11 files). Negative control: with
the implementation reverted, two of the new tests fail because the
symlink target is overwritten by the credential JSON.
@garagon garagon requested a review from a team as a code owner May 9, 2026 00:31
Copy link
Copy Markdown
Contributor

@raubrey-stripe raubrey-stripe left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for this, some quick feedback + windows support!

const resolved = path.resolve(filePath);

if (!force) {
// Atomically create the output file with O_EXCL | O_NOFOLLOW so we never
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we cut down on the comment size here to make this more concise?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in cc1a135: comment now captures the operational why only. The full attack chain stays in the PR body and tests.

handle = await fs.open(
resolved,
// biome-ignore lint/suspicious/noBitwiseInsideUnaryExpression: standard open(2) flag composition
constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY | constants.O_NOFOLLOW,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It looks like O_NOFOLLOW is not supported on windows.

You can see in a similar vulnerability in a filelock python package here took a Windows specific patch in the "Patches" section.

If you're able we'd very much appreciate an investigation into the windows specific fix as well! But in the meantime, could you document this fix only applies to Posix? We can do a check for constants.O_NOFOLLOW !== 0 and make a decision about whether we support --auth-file as well here 🤔 cc @danhill-stripe

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in cc1a135: flag is now constants.O_NOFOLLOW ?? 0 with an inline note on the Windows gap (O_EXCL still prevents overwriting a pre-existing entry, but no full no-follow semantics there). The proposed Windows fix and the plan to validate it on Windows CI before promising it are in the top-level comment.

Addresses review feedback on PR stripe#94. Gates O_NOFOLLOW as
constants.O_NOFOLLOW ?? 0 with an inline note on the Windows gap
(O_EXCL still prevents overwriting a pre-existing entry, but the path
no longer provides full no-follow protection there). Trims in-file
comment; the full attack chain stays in the PR body and tests. Drops
the biome-ignore directive that pointed at a non-existent rule.
@garagon
Copy link
Copy Markdown
Contributor Author

garagon commented May 10, 2026

Thanks for the review and the GHSA reference. Both points make sense.

In-file comment trimmed to the operational "why". Attack chain stays in the PR body and tests.

On Windows: agreed. The flag is now constants.O_NOFOLLOW ?? 0 with an inline note. On Windows the OR is a no-op, so O_EXCL still prevents overwriting a pre-existing entry but no longer provides full no-follow protection.

For the Windows fix itself, I'd rather not promise it without validating on Windows CI first. The pattern I have in mind is open(O_CREAT | O_EXCL)lstat(path)handle.stat() → fail-closed on mismatch, but the fd-vs-reparse-point semantics are subtle. If it holds up I'll add it here. If not, I'll keep this PR scoped to the POSIX fix + documentation and file a follow-up issue so you can decide whether to support --output-file on Windows.

Copy link
Copy Markdown
Contributor

@raubrey-stripe raubrey-stripe left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the updates! Ack on the windows front. One more piece of feedback, let's focus on this first if (force) block. How are you changing the behavior there?

Comment on lines +12 to +24
// Atomically create the credential file so we never write through a
// pre-existing output path. On POSIX, O_NOFOLLOW refuses to follow a
// symlink at the final path component; O_EXCL makes create fail if an
// entry already exists. The 0o600 mode is applied at create time, so
// the file is owner-only the moment it exists.
if (force) {
// fs.unlink operates on the symlink itself rather than its target.
try {
await fs.access(resolved);
await fs.unlink(resolved);
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
}
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry one more q - in what context would this be triggered and how has its behavior changed? Why would we want to unlink a symlinked file? Before fs.access was just testing access permissions - why do we want to now potentially unlink? Wouldn't that invalidate the checks below about not following symlinked files?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the close read. Fair point that the prior --force flow didn't fully match the no-follow contract.

The unlink runs only when --force is set, and it clears the path so the subsequent open with O_CREAT | O_EXCL can succeed. For regular files that preserves the prior --force semantics (replace the existing output file), with the credential landing in a fresh 0o600 inode rather than racing a chmod on an already-open file descriptor.

For symlinks specifically, the no-follow guarantee doesn't cover the existing entry. The unlink runs before O_NOFOLLOW can fire, so the protection only covers the race window after the unlink, not the symlink that was already there. The credential never reaches the link target, but --force ends up silently destroying a symlink the operator may not have intended to remove.

Pushed a fix: lstat first, refuse if the final path is a symlink (with or without --force), unlink and recreate only for non-symlink existing files. O_EXCL | O_NOFOLLOW stays as the race defense between the precheck and the atomic create. With this, --force replaces an existing regular output file and rejects everything else.

Thanks again for the careful pass.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The first part of the change makes sense. Why don't we simply add that check up top and leave the rest of the code unchanged? I'm lost on why we need all the changes below. I'd propose we keep things simply

let existing: Stats | undefined;
  try {
    existing = await fs.lstat(resolved);
  } catch (err) {
    if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
  }

  if (existing?.isSymbolicLink()) {
    throw new Error(
      `OUTPUT_FILE_SYMLINK: ${resolved} is a symbolic link. Refusing to write as symbolic links can open a TOCTOU security threat.`,
    );
  }

if (!force) {
    try {
      await fs.access(resolved);
      throw new Error(
        `OUTPUT_FILE_EXISTS: ${resolved} already exists. Use --force to overwrite.`,
      );
    } catch (err) {
      if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
    }
  }

  await fs.writeFile(resolved, JSON.stringify(data, null, 2), {
    mode: 0o600,
  });
  await fs.chmod(resolved, 0o600);
  return resolved;

That would make the diff much more reasonable.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep, I agree the current diff is larger than ideal.

The part I'm hesitant to drop is the final open path. The lstat check up front is useful for clearer errors, but it is still only a precheck. If we go back to fs.writeFile, the actual write resolves the path again and follows symlinks.

The other subtle bit is mode: 0o600: it only applies when a new file is created. In the --force path, if the destination already exists, writeFile writes first and the explicit chmod happens after the credential bytes are already on disk.

So I think we need two things:

  1. Reject an existing symlink at the output path.
  2. Make the actual credential write create a fresh 0o600 file atomically.

Your suggested lstat block covers the first one. The fs.open(O_CREAT | O_EXCL | O_WRONLY | O_NOFOLLOW, 0o600) part is what covers the second one.

A concrete --force race I'm trying to avoid:

T0: we lstat and see no file at resolved
T1: another local user creates resolved as a symlink to a file they can already read
T2: because force=true, the fs.access check is skipped
T3: fs.writeFile follows the symlink and writes the credential into that target
T4: fs.chmod follows the symlink and locks the target down to 0o600, but the credential was already written through the symlink

I can definitely trim the implementation and comments so the diff is smaller. My proposal would be:

  • keep your lstat precheck for clear symlink errors
  • keep --force limited to replacing existing regular files
  • keep the final write as atomic fs.open(... O_EXCL | O_NOFOLLOW, 0o600) plus handle.write

That should keep the PR focused while preserving the part that closes the TOCTOU.

Addresses review feedback on PR stripe#94. The previous revision unlinked any
pre-existing entry in --force mode and then ran the atomic open with
O_EXCL | O_NOFOLLOW. That cleared the path for the create but also
silently destroyed pre-existing symlinks, and the no-follow guarantee
only covered the race window after the unlink.

This refactor inverts the precheck: lstat first, refuse outright when
the final path is a symlink (with or without --force), and only unlink
and recreate for non-symlink existing files. O_EXCL | O_NOFOLLOW remains
the race defense between the precheck and the atomic create.

Tests updated. The without-force symlink case now expects
OUTPUT_FILE_SYMLINK and asserts the symlink survives. The with-force
case is renamed to make the new contract obvious and gets the same
assertions. The dedicated post-unlink-race test is dropped because its
premise (force unlinks the symlink) no longer applies; the regular-file
race defense is exercised by the existing 0o600 test.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants